diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/(reports)/revenue | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)/revenue')
4 files changed, 203 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx new file mode 100644 index 0000000..0e782a1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -0,0 +1,152 @@ +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import classNames from 'classnames'; +import { colord } from 'colord'; +import { useCallback, useMemo, useState } from 'react'; +import { BarChart } from '@/components/charts/BarChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { CurrencySelect } from '@/components/input/CurrencySelect'; +import { ListTable } from '@/components/metrics/ListTable'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +export interface RevenueProps { + websiteId: string; + startDate: Date; + endDate: Date; + unit: string; +} + +export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { + const [currency, setCurrency] = useState('USD'); + const { formatMessage, labels } = useMessages(); + const { locale, dateLocale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { data, error, isLoading } = useResultQuery<any>('revenue', { + websiteId, + startDate, + endDate, + currency, + }); + + const renderCountryName = useCallback( + ({ label: code }) => ( + <Row className={classNames(locale)} gap> + <TypeIcon type="country" value={code} /> + <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text> + </Row> + ), + [countryNames, locale], + ); + + const chartData: any = useMemo(() => { + if (!data) return []; + + const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data, startDate, endDate, unit]); + + const metrics = useMemo(() => { + if (!data) return []; + + const { sum, count, unique_count } = data.total; + + return [ + { + value: sum, + label: formatMessage(labels.total), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count ? sum / count : 0, + label: formatMessage(labels.average), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count, + label: formatMessage(labels.transactions), + formatValue: formatLongNumber, + }, + { + value: unique_count, + label: formatMessage(labels.uniqueCustomers), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + <Column gap> + <Grid columns="280px" gap> + <CurrencySelect value={currency} onChange={setCurrency} /> + </Grid> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Column gap> + <MetricsBar> + {metrics?.map(({ label, value, formatValue }) => { + return ( + <MetricCard key={label} value={value} label={label} formatValue={formatValue} /> + ); + })} + </MetricsBar> + <Panel> + <BarChart + chartData={chartData} + minDate={startDate} + maxDate={endDate} + unit={unit} + stacked={true} + currency={currency} + renderXLabel={renderXLabel} + height="400px" + /> + </Panel> + <Panel> + <ListTable + title={formatMessage(labels.country)} + metric={formatMessage(labels.revenue)} + data={data?.country.map(({ name, value }: { name: string; value: number }) => ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }))} + currency={currency} + renderLabel={renderCountryName} + /> + </Panel> + </Column> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx new file mode 100644 index 0000000..3e429c1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx @@ -0,0 +1,18 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { Revenue } from './Revenue'; + +export function RevenuePage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx new file mode 100644 index 0000000..e30d54c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx @@ -0,0 +1,21 @@ +import { DataColumn, DataTable } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { formatLongCurrency } from '@/lib/format'; + +export function RevenueTable({ data = [] }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable data={data}> + <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" /> + <DataColumn id="total" label={formatMessage(labels.total)} align="end"> + {(row: any) => formatLongCurrency(row.sum, row.currency)} + </DataColumn> + <DataColumn id="average" label={formatMessage(labels.average)} align="end"> + {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)} + </DataColumn> + <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" /> + <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" /> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx new file mode 100644 index 0000000..fba10f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RevenuePage } from './RevenuePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RevenuePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Revenue', +}; |